package com.ar4j;

import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.commons.beanutils.PropertyUtils;
import org.springframework.transaction.annotation.SpringTransactionAnnotationParser;
import org.springframework.transaction.interceptor.TransactionAttribute;
import org.springframework.transaction.interceptor.TransactionAttributeSource;

import com.ar4j.annotation.AutoGenerated;
import com.ar4j.annotation.Column;
import com.ar4j.annotation.Identifier;
import com.ar4j.annotation.Ignored;
import com.ar4j.annotation.Table;
import com.ar4j.annotation.Type;
import com.ar4j.handler.ActiveRecordMethodHandlerFactory;
import com.ar4j.handler.IMethodHandler;
import com.ar4j.spring.ActiveRecordTransactionAttributeSource;
import com.ar4j.sql.SqlUtils;
import com.ar4j.type.ITypeConverter;
import com.ar4j.util.ClassUtils;
import com.ar4j.util.CollectionUtils;

/**
 * Introspect a domain class to extract the active record meta data 
 *
 */
public final class ActiveRecordMetadataFactory {
  public static final String DEFAULT_ID_PROPERTY_NAME = "id";
  public static final Class<?> NO_TYPE_MARKER = Type.class;
  
  private static final Set<String> DEFAULT_IGNORED_PROPERTIES = new HashSet<String>(Arrays.asList(new String[] {
    "class"
  }));
  
  private static final Set<String> DEFAULT_IGNORED_METHODS = new HashSet<String>(Arrays.asList(new String[] {
    "wait", "getClass", "notify", "notifyAll"
  }));
  
  
  private ActiveRecordMetadataFactory() {
    // UTILITY CLASS - NO CONSTRUCTOR FOR YOU!
  }
  
  /**
   * Parse and extract active record meta data (should go over the entire class and its annotations)
   */
  public static ActiveRecordMetadata getMetadata(Class<?> domainClass, String factoryName) {
    validateDomainClass(domainClass);
    
    ActiveRecordMetadata out = new ActiveRecordMetadata();
    
    try {
      out.setFactoryName(factoryName);
      out.setBaseClass(domainClass);
      
      // get table name (via annotation or class name)
      out.setTableName(getTableName(domainClass));
      
      // record method handlers (properties and other methods)
      Map<Method, IMethodHandler> methodHandlerMap = new HashMap<Method, IMethodHandler>();
      out.setMethodHandlerMap(methodHandlerMap);
      recordDomainPropertyMetadata(domainClass, methodHandlerMap, out);
      recordNonPropertyMethodHandlers(domainClass, methodHandlerMap, out);
      
      // initialize all handlers
      for(Map.Entry<Method, IMethodHandler> entry : methodHandlerMap.entrySet()) {
        entry.getValue().init(out, entry.getKey());
      }
            
    } catch(Throwable e) {
      throw new RuntimeException("Could not get metadata for: " + domainClass, e);
    }
    
    return out;
  }
  
  /**
   * Make sure that the given class implements ActiveRecord and is an abstract class
   */
  private static void validateDomainClass(Class<?> domainClass) {
    
    // make sure its an active record
    if(!ActiveRecord.class.isAssignableFrom(domainClass)) {
      throw new RuntimeException("Provided domain class does not implement ActiveRecord: " + domainClass.getName());
    }
    
    // make sure its an abstract class
    if(!Modifier.isAbstract(domainClass.getModifiers()) || Modifier.isInterface(domainClass.getModifiers())) {
      throw new RuntimeException("Provided domain must be abstract and not an interface: " + domainClass.getName());
    }
    
  }
  
  /**
   * Scans the given domain class for information about its properties (columns in the backing store).
   * 
   * This method is responsible for recording the metadata about:
   * property names
   * property types
   * read methods
   * write methods
   * auto-generated properties
   * identifier property
   * type conversions
   * property->column name mappings
   * 
   * Handlers are generated for retrieving and setting all properties found in the domain class.
   * 
   */
  private static void recordDomainPropertyMetadata(Class<?> domainClass, Map<Method, IMethodHandler> methodHandlerMap, ActiveRecordMetadata metadata) 
    throws SecurityException, NoSuchFieldException {
    
    TransactionAttributeSource transactionAttributeSource = new ActiveRecordTransactionAttributeSource(new SpringTransactionAnnotationParser());
    
    Set<String> propertyNames = new LinkedHashSet<String>();
    Map<String, Class<?>> propertyTypeMap = new HashMap<String, Class<?>>();
    Map<String, Method> propertyReadMethods = new HashMap<String, Method>();
    Map<String, Method> propertyWriteMethods = new HashMap<String, Method>();
    Set<String> propertiesAutogeneratedInBackingStore = new LinkedHashSet<String>();
    String identifierPropertyName = null;
    Map<String, ITypeConverter<?, ?>> propertyTypeConvertorMap = new HashMap<String, ITypeConverter<?, ?>>();
    Map<String, String> propertyColumnNameMap = new HashMap<String, String>();
    Map<Method, TransactionAttribute> methodTransactionAttributeMap = new HashMap<Method, TransactionAttribute>();
    for(PropertyDescriptor property : PropertyUtils.getPropertyDescriptors(domainClass)) {
      if(property.isHidden() || DEFAULT_IGNORED_PROPERTIES.contains(property.getName())) {
        continue;
      }
      
      // skip ignored properties, they will get picked up by passthrough handlers later
      Ignored ignoredAnno = ClassUtils.getPropertyAnnotation(Ignored.class, domainClass, property);
      if(ignoredAnno != null) {
        continue;
      }
      
      propertyNames.add(property.getName());
      propertyTypeMap.put(property.getName(), property.getPropertyType());
      
      Method readMethod = property.getReadMethod();
      if(readMethod != null && Modifier.isPublic(readMethod.getModifiers())) {
        methodHandlerMap.put(readMethod, ActiveRecordMethodHandlerFactory.getHandlerForPropertyGetter());
        propertyReadMethods.put(property.getName(), readMethod);
        
        // extract annotation from method only, no default transaction attribute from the domain class
        TransactionAttribute transactionAttribute = transactionAttributeSource.getTransactionAttribute(readMethod, null);
        if(transactionAttribute != null) {
          methodTransactionAttributeMap.put(readMethod, transactionAttribute);
        }
      }
      
      Method writeMethod = property.getWriteMethod();
      if(writeMethod != null && Modifier.isPublic(writeMethod.getModifiers())) {
        methodHandlerMap.put(writeMethod, ActiveRecordMethodHandlerFactory.getHandlerForPropertySetter());
        propertyWriteMethods.put(property.getName(), writeMethod);
        
        // extract annotation from method only, no default transaction attribute from the domain class
        TransactionAttribute transactionAttribute = transactionAttributeSource.getTransactionAttribute(writeMethod, null);
        if(transactionAttribute != null) {
          methodTransactionAttributeMap.put(writeMethod, transactionAttribute);
        }
      }
      
      // check for identifier annotation on field or read method
      Identifier idAnno = ClassUtils.getPropertyAnnotation(Identifier.class, domainClass, property);
      boolean isIdAutogenerated = false;
      if(idAnno != null) {
        if(identifierPropertyName == null) {
          identifierPropertyName = property.getName();
          isIdAutogenerated = idAnno.autogenerated();
        } else {
          throw new RuntimeException("Cannot have two identifiers in an active record: " + identifierPropertyName + ", " + property.getName());
        }
      }
      
      // detect autogenerated properties
      AutoGenerated autogenAnno = ClassUtils.getPropertyAnnotation(AutoGenerated.class, domainClass, property);
      if(isIdAutogenerated || autogenAnno != null) {
        propertiesAutogeneratedInBackingStore.add(property.getName());
      }
      
      // check for type converter
      Type typeAnno = ClassUtils.getPropertyAnnotation(Type.class, domainClass, property);
      if(typeAnno != null) {
        ITypeConverter<?, ?> converter = getTypeConverterFromAnnotation(domainClass, property, typeAnno);
        propertyTypeConvertorMap.put(property.getName(), converter);
      }
      
      // decipher column name, or use default
      String column = null;
      Column columnAnno = ClassUtils.getPropertyAnnotation(Column.class, domainClass, property);
      if(columnAnno != null) {
        column = columnAnno.value();
      } else {
        column = SqlUtils.convertPropertyNameToUnderscoreName(property.getName());
      }
      propertyColumnNameMap.put(property.getName(), column);

    }
    
    // set to default ID if needed
    if(identifierPropertyName == null) {
      identifierPropertyName = DEFAULT_ID_PROPERTY_NAME;
      propertiesAutogeneratedInBackingStore.add(identifierPropertyName);
    }
    
    // validate identifier property type
    Class<?> identifierType = propertyTypeMap.get(identifierPropertyName);
    if(!Number.class.isAssignableFrom(identifierType)) {
      throw new IllegalArgumentException("Identifier property (" + identifierPropertyName + ") must be assignable to Number");
    }
    
    metadata.setPropertyNames(propertyNames);
    metadata.setPropertyTypeMap(propertyTypeMap);
    metadata.setPropertyReadMethods(propertyReadMethods);
    metadata.setPropertyWriteMethods(propertyWriteMethods);
    metadata.setIdentifierPropertyName(identifierPropertyName);
    metadata.setPropertiesAutogeneratedInBackingStore(propertiesAutogeneratedInBackingStore);
    metadata.setPropertyTypeConvertorMap(propertyTypeConvertorMap);
    metadata.setPropertyColumnNameMap(propertyColumnNameMap);
    metadata.setMethodTransactionAttributeMap(methodTransactionAttributeMap);
    
  }
  
  /**
   * Scans the given domain class and records metadata and handlers for all methods that are not related to setting/getting
   * property values. This includes all methods defined by the ActiveRecord interface as well as any dynamically named methods
   * defined in the domain class itself.
   */
  private static void recordNonPropertyMethodHandlers(Class<?> domainClass, Map<Method, IMethodHandler> methodHandlerMap, ActiveRecordMetadata metadata) {
    
    // transaction attribute extraction setup
    TransactionAttributeSource transactionAttributeSource = new ActiveRecordTransactionAttributeSource(new SpringTransactionAnnotationParser());
    Map<Method, TransactionAttribute> methodTransactionAttributeMap = metadata.getMethodTransactionAttributeMap();
    if(methodTransactionAttributeMap== null) {
      methodTransactionAttributeMap = new HashMap<Method, TransactionAttribute>();
      metadata.setMethodTransactionAttributeMap(methodTransactionAttributeMap);
    }
    
    // gather the names of methods that need handling
    Map<String, List<Method>> methodsToHandle = new HashMap<String, List<Method>>();
    for(Method method : domainClass.getMethods()) {
      // ignore methods already mapped or marked to be ignored
      if(DEFAULT_IGNORED_METHODS.contains(method.getName()) || methodHandlerMap.containsKey(method)) {
        continue;
      }
      
      // public concrete methods automatically get a no-op handler, everything else needs a 
      // handler to be created for it
      if(method.getDeclaringClass() == domainClass && 
          Modifier.isPublic(method.getModifiers()) && 
          !Modifier.isAbstract(method.getModifiers())) {
     
        methodHandlerMap.put(method, ActiveRecordMethodHandlerFactory.getHandlerForArbitraryMethod());
        TransactionAttribute transactionAttribute = transactionAttributeSource.getTransactionAttribute(method, domainClass);
        if(transactionAttribute != null) {
          methodTransactionAttributeMap.put(method, transactionAttribute);
        }
        
      } else {
        // ignore any methods the definition tells us to
        Ignored ignoreAnno = method.getAnnotation(Ignored.class);
        if(ignoreAnno == null) {
          CollectionUtils.addToMapOfLists(method.getName(), methodsToHandle, method);
        }
      }
    }
    
    // get a handler for each method name that needs handling
    for(String methodName : methodsToHandle.keySet()) {
      IMethodHandler handler = ActiveRecordMethodHandlerFactory.getHandlerForMethod(methodName, metadata);
      for(Method method : methodsToHandle.get(methodName)) {
        methodHandlerMap.put(method, handler);
        
        TransactionAttribute transactionAttribute = transactionAttributeSource.getTransactionAttribute(method, domainClass);
        if(transactionAttribute != null) {
          methodTransactionAttributeMap.put(method, transactionAttribute);
        }
      }
    }
  }
  
  /**
   * @return the table name for the given domain class, found either via the Table annotation
   * or by converting the class name to underscore separated capitalized text.
   */
  private static String getTableName(Class<?> domainClass) {
    
    // look for the annotation first
    Table tableAnno = domainClass.getAnnotation(Table.class);
    String tableName = null;
    if(tableAnno != null) {
      tableName = tableAnno.value();
    } else {
      tableName = SqlUtils.convertPropertyNameToUnderscoreName(domainClass.getSimpleName()).toUpperCase();
    }
   
    return tableName;
  }
  
  /**
   * Creates a new type converter from the given Type annotation for the given
   * property in the domain class.
   */
  @SuppressWarnings("unchecked")
  private static <E, F> ITypeConverter<E, F> getTypeConverterFromAnnotation(Class<?> domainClass, PropertyDescriptor property, Type typeAnno) {
    Class<?> converterClass = typeAnno.converterClass();
    if(!ITypeConverter.class.isAssignableFrom(converterClass)) {
      throw new IllegalArgumentException("Converter class provided is not a subclass of ITypeConverter. property: " + property.getName() + " in class " +
          domainClass.getName() + " specified converter class: " + converterClass.getName());
    }
    
    ITypeConverter<E, F> out = null;
    try {
      out = (ITypeConverter<E, F>) converterClass.newInstance();
      
      if(typeAnno.objectType() != NO_TYPE_MARKER || typeAnno.storageType() != NO_TYPE_MARKER) {
        out.setTypes((Class<E>) (typeAnno.objectType() == NO_TYPE_MARKER ? null : typeAnno.objectType()), 
            (Class<F>) (typeAnno.storageType() == NO_TYPE_MARKER ? null : typeAnno.storageType()));
      }
      
    } catch(Throwable e) {
      throw new RuntimeException("Could not create a new converter of type " + converterClass.getName() + " for " + property.getName() + 
          " in class " + domainClass.getName(), e);
    }
    
    return out;
  }
  
}
